fix(workflows): reject a condition that has no {{ }} block - #4182
Conversation
`evaluate_condition` resolves its argument through `evaluate_expression`,
which only substitutes `{{ ... }}` blocks. A string with no such block
comes back unchanged and — unless it reads `true`/`false` — is then
coerced by `bool()`. So a condition authored without the braces is never
evaluated at all:
evaluate_condition("inputs.count > 100", ctx) -> True
evaluate_condition("{{ inputs.count > 100 }}", ctx) -> False
with `inputs.count == 5` in both cases. An `if` step always takes `then`,
and a `while`/`do-while` step always runs to `max_iterations` — ten agent
invocations for a loop the author expected to stop.
This is the same silent-truthiness authoring mistake the three step
validators already reject for a list/dict/number condition, and it is
easier to make: GitHub Actions accepts a bare expression in `if:`, so the
brace-less form is a habit to bring here.
Adds `condition_is_never_evaluated()` and wires it into the `if`,
`while` and `do-while` validators, so the mistake surfaces at validation
with the corrected form spelled out. Boolean literals, real bools, empty
strings and any string containing `{{` stay valid — runtime behaviour is
unchanged.
There was a problem hiding this comment.
Pull request overview
Adds validation to reject brace-less workflow conditions that would otherwise always evaluate as true.
Changes:
- Adds a shared condition-validation helper.
- Integrates validation into
if,while, anddo-whilesteps. - Adds runtime and validator regression tests.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/workflows/expressions.py |
Adds condition detection helper. |
src/specify_cli/workflows/steps/if_then/__init__.py |
Validates if conditions. |
src/specify_cli/workflows/steps/while_loop/__init__.py |
Validates while conditions. |
src/specify_cli/workflows/steps/do_while/__init__.py |
Validates do-while conditions. |
tests/unit/test_condition_expression_block.py |
Adds regression coverage. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 5/5 changed files
- Comments generated: 4
- Review effort level: Balanced
Two gaps in the condition validator, both raised in review.
An opening `{{` with no `}}` after it is never substituted either:
_interpolate_expressions takes its `raw_close == -1` branch and appends
the tail verbatim. So `condition: "{{ inputs.count > 100"` -- and the
reversed `"}} inputs.count > 100 {{"`, whose only `{{` is last -- come
back unchanged and are coerced to true exactly like a brace-less string.
The helper now looks for a complete block rather than an opening one.
The suggested correction was interpolated into a double-quoted scalar,
so a condition containing a double quote produced YAML that does not
parse: `condition: "{{ inputs.name == "zzz" }}"` raises a ParserError.
format_condition_correction() now picks the quoting from the content and
drops a stray delimiter instead of nesting a second one, so the message
stays paste-ready. All three validators share it.
Tests: 30 more cases -- the incomplete forms, and a YAML round trip over
conditions holding single quotes, double quotes, both, and backslashes,
asserting each correction loads back exactly and is not re-flagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both review points were correct — thanks. Fixed in c0c291c. 1. Unterminated / reversed delimiters slipped through Confirmed against the interpolator rather than assumed.
The helper now requires a complete block: it finds the first 2. The correction was not valid YAML Reproduced exactly as described — Added
Tests — the file goes from 40 to 70 cases. The new ones cover the incomplete forms, and run every correction through
|
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
…h json.dumps
Both follow-up review points were right.
The completeness check used a plain `find("}}")`, but the substituter closes a
block with a quote-aware scan. So `condition: "{{ inputs.x == '}}'"` looked
complete to the validator while `_interpolate_expressions` found no close, fell
to its raw-close branch, evaluated a truncated body and left residual text
(`False'`) -- a non-empty string, hence true. Rather than restate the quote
rules a third time, the scan moves out of `_interpolate_expressions` into
`_find_block_close`, which the validator now calls: the check and the
substitution it predicts can no longer disagree. A `}}` that is genuinely
inside a string argument still does not close early, so
`{{ inputs.text | default('}}') }}` and `{{ inputs.x == '}}' }}` stay accepted.
The correction's quoting enumerated the characters it escaped, and the
enumeration was short: a condition loaded from a YAML literal block can carry a
newline, which a double-quoted scalar folds, so the corrected form did not
round-trip. `json.dumps` decides it instead -- every JSON string is a valid
YAML double-quoted scalar and it escapes quotes, backslashes, newlines and the
other control characters. `ensure_ascii=False` keeps a non-ASCII operand
readable rather than expanding it into numeric escapes.
Tests: 70 -> 83. The quoted-delimiter condition joins the incomplete-block set,
and the round-trip set gains multiline, newline-with-quote, tab, carriage
return and non-ASCII operands. All four new cases fail on the previous commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@mnriem — both follow-up findings addressed in 1. The Confirmed exactly as described:
Rather than restate the quote rules a third time in this module, I lifted the scan out of 2. Hand-rolled quoting missed control characters Also reproduced — a condition carrying a newline did not round-trip:
Switched to Tests 70 → 83. The quoted-delimiter condition joins the incomplete-block set; the round-trip set gains multiline, newline-with-quote, tab, carriage return and a non-ASCII operand. All four new cases fail on the previous commit ( Verification on this head: |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/workflows/expressions.py:730
- Non-empty whitespace is not false at runtime.
evaluate_condition()strips only while checking thetrue/falsekeywords and then falls through tobool(result), sobool(" ")isTrue. Excluding it here lets every validator accept a condition that silently takesthenor loops tomax_iterations, exactly the failure this helper is intended to reject. Please distinguish the truly empty string from non-empty whitespace (and update the tests that currently mark whitespace valid), or normalize whitespace in the runtime evaluator.
This issue also appears on line 765 of the same file.
stripped = condition.strip()
if not stripped or stripped.lower() in ("true", "false"):
return False
src/specify_cli/workflows/expressions.py:768
- The correction only removes delimiters at the edges, but the validator also flags malformed delimiters in the middle. For example,
prefix {{ inputs.readyis flagged and corrected to"{{ prefix {{ inputs.ready }}"; likewiseinputs.ready }} suffixretains the inner}}. These suggestions do not evaluate the intended condition and may subsequently evade this validator because they contain a complete outer/first block. Please either sanitize stray delimiters outside quoted operands or avoid offering an automatic correction for malformed-block cases.
core = str(condition).strip()
core = re.sub(r"^\s*(\{\{|\}\})\s*", "", core)
core = re.sub(r"\s*(\{\{|\}\})\s*$", "", core).strip()
return json.dumps("{{ " + core + " }}", ensure_ascii=False)
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…nesting a block
Two review findings, both reproduced against the code before changing it.
**1. Non-empty whitespace was excluded, and it should not have been.**
The docstring claimed a whitespace condition "coerces to False, which is a
definite answer". That is true only of the empty string. Measured:
evaluate_condition("") -> False
evaluate_condition(" ") -> True
evaluate_condition("\t\n ") -> True
`evaluate_condition` strips only while testing the true/false keywords, then
falls through to `bool()` on the raw string -- and
`test_condition_whitespace_only_string_stays_truthy` pins that on purpose. So
`condition: " "` is exactly the silent always-true this helper exists to
catch, and it was sailing through. Fixed at validation time rather than in the
evaluator, because that runtime behaviour is deliberate.
The empty string stays excluded: it really does coerce to False.
**2. The correction only removed edge delimiters, so it could nest one.**
"prefix {{ inputs.ready" -> "{{ prefix {{ inputs.ready }}"
The suggestion carried an unclosed inner block, and because its *outer* block
was complete, `condition_is_never_evaluated` waved the corrected form straight
back through. Same for a trailing `}}`.
`_strip_stray_delimiters` now removes every delimiter, and is quote-aware for
the reason the rest of this module is: `inputs.x == '}}'` holds a delimiter as
data, and a blanket `re.sub` would eat it and change what the condition
compares. `_find_top_level` could not be reused -- it counts `{`/`}` as bracket
depth, so it never reports a `{{` as a token at all.
"prefix {{ inputs.ready" -> "{{ prefix inputs.ready }}"
"inputs.ready }} suffix" -> "{{ inputs.ready suffix }}"
"{{ inputs.x == '}}'" -> "{{ inputs.x == '}}' }}" (data kept)
'{{ inputs.name == "a b"' -> '{{ inputs.name == "a b" }}' (spacing kept)
Whitespace collapses only where a delimiter was removed; inside a quoted
operand it is untouched.
Tests: the two fixtures that asserted whitespace was valid are corrected, and
five cases added for interior delimiters, quoted delimiters and quoted spacing.
87 pass in tests/unit/test_condition_expression_block.py.
tests/test_workflows.py is 20 failed / 903 passed both with and without this
change -- all twenty are symlink tests that need Windows Developer Mode, and
the counts are identical with the diff stashed.
|
Both suppressed findings in the latest Copilot pass are real. Fixed in 1. Non-empty whitespace was excluded, and it should not have been My docstring claimed a whitespace condition "coerces to
That existing test is why I fixed it at validation time rather than normalizing in the evaluator, which was Copilot's alternative: normalizing would contradict a behaviour the suite intentionally holds. The empty string stays excluded, because it really does coerce to 2. The correction removed only edge delimiters, so it could nest one The suggestion carried an unclosed inner block, and because the outer block was complete,
Whitespace collapses only where a delimiter was removed; inside a quoted operand nothing is touched. One consequence I would rather name than leave for you to find. For a whitespace-only condition there is nothing to wrap, so the message ends Verification.
@mnriem — this is on top of the two findings you raised, which went in as |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/workflows/expressions.py:752
- This does not always mean the condition is “never evaluated.”
_interpolate_expressionsfalls back to the first raw}}when the quote-aware scan fails and evaluates the truncated body. For example,{{ inputs.missing | default('oops }}reaches_apply_filterand raisesValueError, while this helper returnsTrue, causing all three validators to report that it is always true. Please distinguish genuinely uninterpolated text from malformed blocks that take the raw-close evaluation path, and report the latter as malformed rather than always true.
return _find_block_close(stripped, open_at) == -1
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
…luated
Third review finding, and like the first two it reproduces. `condition_is_never_evaluated`
returned True for any `{{` the quote-aware scan could not close -- but
`_interpolate_expressions` does not treat those alike. Its own comment spells out
two sub-cases, and only one is "never evaluated":
* no raw `}}` in the tail -> the text is emitted verbatim, so bool() makes it
true. Genuinely uninterpolated.
* a raw `}}` further along -> that is used as the close and the truncated body
*is* evaluated.
Measured:
{{ inputs.count > 100 -> True (never evaluated)
}} inputs.count > 100 {{ -> True (never evaluated)
{{ inputs.x == '}}' -> True (raw-close path)
{{ inputs.missing | default('oops }} -> raises ValueError
That last one made the old message wrong on both halves: it is evaluated, and it
does not end up true -- it ends the run in `_apply_filter`.
Adds `condition_has_malformed_expression_block` and gives it its own branch in the
three validators, because the two faults need opposite advice: one says "you forgot
the braces", the other says "your delimiters or quotes do not balance". The two
predicates are mutually exclusive, pinned by a test over every fixture.
The malformed branch deliberately offers **no** paste-ready correction. The fault is
unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter --
for `{{ inputs.missing | default('oops }}` it emits `"{{ inputs.missing | default('oops }} }}"`,
which is not a fix. This is the same "avoid offering an automatic correction for
malformed-block cases" the reviewer raised earlier; it applies exactly here.
Also renders a blank correction as `"{{ }}"` rather than the double-spaced `"{{ }}"`
that concatenation produced for a whitespace-only condition.
106 pass in tests/unit/test_condition_expression_block.py. Across
tests/test_workflows.py + tests/unit the run is 22 failed / 1189 passed, and 22
failed / 1170 passed with this diff stashed -- identical failures, all Windows
symlink cases, none touching conditions or expressions.
|
Third finding, and it holds too. Fixed in
That last one made the message wrong on both halves: it is evaluated, and it does not end up true — it ends the run in Added The malformed branch deliberately offers no correction, which closes the loop on your earlier point about not auto-correcting malformed-block cases — it applies exactly here. The fault is unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter: for which is not a fix. Naming the fault beats handing back something that looks authoritative and is not. That also resolves the loose end I flagged last round: a blank correction now renders Verification. 106 pass in @mnriem — that is three review rounds of findings now addressed ( |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/specify_cli/workflows/expressions.py:787
- Only the first expression block is inspected. A condition such as
{{ inputs.name }} {{ inputs.missing | default('oops }}is accepted because the first block closes, although the later malformed block raisesValueErrorat runtime (the behavior is already pinned intests/test_workflows.py:301-319). Scan every opener so malformed later blocks fail validation too.
open_at = stripped.find("{{")
if open_at == -1:
return False
if _find_block_close(stripped, open_at) != -1:
return False
return stripped.find("}}", open_at + 2) != -1
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
Both condition validators stopped at the first `{{`. A condition whose first
block closes was accepted regardless of what followed, so a later unterminated
block escaped validation entirely — the case Copilot raised:
{{ true }} and {{ inputs.ready -> both validators returned False
Interpolation leaves `and {{ inputs.ready` in the result and bool() makes the
condition always true, which is exactly the silent-branching defect these
validators exist to catch. The same hole applied to the malformed class:
{{ inputs.name }} {{ inputs.missing | default('oops }} -> raises at run time
Add `_first_unclosable_block`, which walks blocks the way
`_interpolate_expressions` does — continuing past each block that closes — and
reports how the first unclosable one will fail: `evaluated` when a raw `}}`
follows (the fallback truncates and evaluates), `verbatim` when none does.
Both validators now read from it, so they cannot disagree with the substitution
they predict.
Two wording fixes fall out of scanning further:
- The never-evaluated message said the condition "has no complete '{{ }}'
block". With an earlier complete block that is false, so it now says the
condition "is not a single complete '{{ }}' block".
- `condition_has_malformed_expression_block`'s docstring said the truncated body
raises ValueError. It does for `default('oops`, but `{{ inputs.x == '}}'`
evaluates to the residual `"False'"` instead. Measured both; the docstring now
says either can happen and the error message never claimed otherwise.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 116 passed (was 106)
- tests/unit + tests/test_workflows.py 1199 passed (was 1189), 22 failed
before and after — all pre-existing symlink tests that need Windows elevation.
Mutation-checked: restoring the stop-after-first-block behaviour fails exactly
the 10 new parametrised cases and nothing else.
|
@mnriem — the remaining Copilot finding is addressed in The hole. Both validators stopped at the first
Both slipped through — the first is the silent-branching defect this PR exists to catch. The fix. A shared Two wording fixes fell out of scanning further, and I want to flag the second because I had it wrong:
Verification on Python 3.11: The 22 failures in that second run are identical before and after this commit — all Mutation-checked: restoring the stop-after-first-block behaviour fails exactly the 10 new parametrised cases and nothing else, so the new fixtures pin this specific defect rather than passing incidentally. The other Copilot threads on this PR are marked outdated — they were addressed in |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/workflows/expressions.py:880
format_condition_correctionis also called for inputs that cannot be fixed by adding delimiters. For example, whitespace becomes"{{ }}", while{{ inputs.name == 'abcbecomes"{{ inputs.name == 'abc }}"with the quote still unbalanced. Both are presented as paste-ready corrections even though there is no valid expression to wrap. Detect an empty core or unbalanced quotes and report a tailored validation error withoutWrap the expressioninstead.
core = _strip_stray_delimiters(str(condition)).strip()
# A blank core has nothing to wrap; render the empty block rather than the
# double-spaced "{{ }}" that string concatenation would otherwise produce.
body = "{{ " + core + " }}" if core else "{{ }}"
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Thank you! |
|
The suppressed finding on I measured what pasting each suggested correction actually does, rather than reasoning about it:
Both suggestions silently invert the condition. The blank core interpolates to the empty string; the open quote survives wrapping, so the raw-close fallback evaluates a truncated comparison whose result is the string The fix.
One correction to my own work, since it happened in this commit. My first draft of the unbalanced-quote message said the wrapped form "stays always true". The new test failed and showed it returns Verification on Python 3.11: The 22 failures in the wider run are identical before and after — the Mutation-checked: removing either gate fails exactly the 9 new parametrised cases and nothing else. That clears every Copilot thread on this PR, including the suppressed one. @mnriem — ready for another look whenever suits. |
|
Correction to my comment above: this merged at 13:24:16Z and I pushed What did land in Thanks for the merge. |
…065-stable-block-identifiers * 'main' of https://github.com/github/spec-kit: (38 commits) [extension] Update Security Review extension to v2.0.0 (github#4223) fix(presets): reject duplicate provides.templates name+type entries (github#4191) fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (github#4190) Update Intake Sequencing Governance preset to v0.2.3 (github#4235) Update MAQA — Multi-Agent & Quality Assurance extension to v0.1.6 (github#4234) [bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (github#4205) [preset] Add Inventory Alignment preset to community catalog (github#4229) [extension] Add Spec Inventory extension to community catalog (github#4228) [extension] Update Architecture Guard extension to v2.3.6 (github#4224) Update SpecKit Companion extension to v0.20.2 (github#4225) fix(workflows): reject a condition that has no {{ }} block (github#4182) fix: raise feature assessment credit budget (github#4222) [extension] Add AgentDocx extension to community catalog (github#4184) fix(integrations): report a falsy non-mapping integration descriptor as a shape error (github#4187) Update Autonomous Run Governance preset to v0.4.1 (github#4203) fix(workflows): validate dispatch defaults (github#4181) Update Atlas extension display name in community catalog (github#4202) Add Closed Vocabulary Check preset to community catalog (github#4201) fix(utils): narrow bare except Exception in merge_json_files (github#4189) chore: release 0.16.5, begin 0.16.6.dev0 development (github#4206) ...
Defect
evaluate_conditionresolves its argument throughevaluate_expression, which only substitutes{{ ... }}blocks. A string with no such block comes back unchanged, and — unless it readstrue/false— is then coerced bybool(). So a condition authored without the braces is never evaluated at all.With
inputs.count == 5:Every brace-less condition is true, whatever it says. An
ifstep always takesthen; awhile/do-whilestep never terminates on its condition and runs tomax_iterations— ten agent invocations for a loop the author expected to stop after one.Nothing reports it. The workflow validates, runs, and takes the wrong branch silently.
Why this is worth a validation error
The three step validators already reject a list/dict/number condition, and the comment there states the reason exactly:
A brace-less string is the same failure mode, and a likelier mistake: GitHub Actions accepts a bare expression in
if:(if: github.event_name == 'push'), so an author arriving from Actions writes the brace-less form by habit — and unlike[1, 2],condition: inputs.count > 100looks completely correct on the page.Fix
condition_is_never_evaluated()inexpressions.py, wired into theif,whileanddo-whilevalidators. The error names the problem and hands back the corrected form:Validation only — no runtime behaviour changes. Still valid, and covered by tests:
"{{ ... }}"in any position,"true"/"false"in any case, realbools, empty and whitespace strings, and every non-string type (already handled by the branch above).Tests
New
tests/unit/test_condition_expression_block.py, 40 cases:FalsevsTrue— so the defect stays documented even if the validator changesThe same 22 pre-existing failures in both runs — all
symlink_toon Windows without the privilege (OSError: [WinError 1314]), unrelated to this change. Everything added is the +40 new tests.